最近在项目里做了一个数据同步的功能。需要把两个数据库里的数据做同步,在业务不繁忙的时候跑一次,核对库里每一条数据,主要是为了保证数据最终一致,做的一个保底工作。之前项目里同步的代码是单线程的,我改成了基于线程池的,但是在上线前的一些问题却引起了我的思考。
现象
程序的代码还是比较简单的,大概类似下面:
1 | // 两个参数分别为线程数量和总任务数 |
为了确定线程的数量,我逐步增加线程数记录系统各项数值。下面是在一台 RMBP 上做的测试。
| T | QPS | CPU | DBCPU |
| | | | | |
| 1 | 9000 | 37 | 81 |
| 2 | 14000 | 60 | 150 |
| 3 | 15500 | 70 | 195 |
| 4 | 16500 | 90 | 260 |
可以发现随着线程数增加,性能会逐步提高,但是当线程数提高到一定程度之后,性能不增反降(这部分数据没放)。如果继续增加线程数进行测试,会发现系统的某些性能指标会被耗尽,程序开始报错。
1 | recovering from panic: dial tcp 127.0.0.1:3306: getsockopt: operation timed out |
为什么会出现这种情况呢?在讨论这些问题之前,先回顾一下 goroutine 的设计与实现。
goroutine 设计
事实上,在 golang 中的 goroutine 已经不是传统意义上的线程了。传统的操作系统提供的线程在使用时有一些限制,超过一定数量之后会对性能造成影响。而 golang 提供的 goroutine 是一种 green thread。
Green thread 和 NodeJS 的异步回调方案有类似的地方,都在底层调用的系统的异步 IO 系统调用。
- 异步回调方案:所有 IO 操作的 API 都设计成异步回调形式,底层调用异步的系统调用(如epool、kqueue等)。同时,也可能提供同步版本的 IO 操作(如fs.readFileSync)
- Green thread 方案:所有 IO 操作底层实际上事异步调用,但是在语言中却表现的像一个同步调用。当 IO 操作需要等待时,语言的 runtime 自动调度系统级线程到另一个 Green thread 中。写代码的时候感觉好像是同步的,仿佛在同一个线程完成的,但实际上系统可能切换了线程,但程序对此无感。
由此可见,Green thread 在语言设计上比异步回调方案要略胜一筹,不会出现“冲击波”的现象(具体在 NodeJS 中如何避免“冲击波”代码可以看我这篇文章await & async 深度剖析),但在性能上实际是差不多的,都避免了大量使用操作系统级的线程带来的性能问题,同时又能充分的利用 CPU。
但是,今天我想谈的问题并不是 CPU 利用率/线程数量的问题,这个问题已经被上述两种设计方案比较完美的解决了。
CPU 以外的资源瓶颈
在实际中,更多遇到的是 cpu /线程数量之外的资源瓶颈。比如锁、数据库链接、tcp链接。举个例子,假如做个爬虫应用,每个 goroutine 爬一个网页,golang 虽然号称百万级别的 goroutine ,但是你每个 goroutine 里面创建一个 tcp 链接,不到 5 万个 goroutine 就会把系统的 tcp 资源耗尽。
回到文章最上面的情况,在 goroutine 里调用了 dao.SyncSingleUser
函数,这个是一个数据库操作,不可避免的会有 socket 操作、硬盘操作。如果将所有数据库操作都无脑的放到 goroutine 中执行,当资源出现瓶颈之后,大量 goroutine 会阻塞或报错。
因此,我在项目里使用了一个 goroutine 池,来确保不会过多使用系统资源导致崩溃。那么问题来了,池里究竟该放多少线程?
又回到了最初使用系统线程时遇到的问题,不过这次导致问题的不再是线程资源,而是其他资源瓶颈(如 tcp、数据库链接 等)。这些资源比线程资源更加复杂,更加难以把控。更加难做 benchmark,也就更难找出一个通用的方法来解决实际场景的问题。
没有银弹
思考过后,我在网上开始搜索解决方案。发现这篇文章的作者和我做了类似的思考:《并发之痛 Thread,Goroutine,Actor》。作者最后得出 Actor 模型能解决一些问题(不过我才疏学浅至今不怎么理解 Actor 模型),但并发带来的问题还远远没到解决的程度。
革命尚未成功 同志任需努力
所以说,软件工程没有银弹,路漫漫其修远兮,吾将上下而求索。